chore: [SDK-4406] prepare Unity demo app for Appium E2E tests#869
chore: [SDK-4406] prepare Unity demo app for Appium E2E tests#869fadi-george wants to merge 54 commits into
Conversation
Row TextField names were derived from `_rows.Count` at construction time, but RemoveRow does not renumber survivors. After Add+Remove(middle)+Add, the new row's name collided with an existing survivor's name, and AccessibilityBridge.WalkAndUpsert dedupes by name (first-wins) — so the new row was silently dropped from the a11y tree and Appium taps landed on the stale row. Switch to a monotonic `_nextRowIndex++` so names are permanently unique regardless of remove ordering. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
|
@claude review |
| private static string AndroidNativeRole(VisualElement el) | ||
| { | ||
| if (AndroidClickTargets.ContainsKey(el)) | ||
| return "button"; | ||
|
|
||
| return el switch | ||
| { | ||
| TextField => "input", | ||
| BaseBoolField => "toggle", | ||
| OneSignalDemo.UI.SwitchToggle => "toggle", | ||
| Button => "button", | ||
| _ => "text", | ||
| }; | ||
| } |
There was a problem hiding this comment.
🔴 🟡 MultiSelectRemoveDialog registers each Toggle via AccessibilityBridge.RegisterE2ETapTarget (MultiSelectRemoveDialog.cs:65-69), which adds the Toggle to AndroidClickTargets. But AndroidNativeRole (AccessibilityBridge.cs:728-740) checks AndroidClickTargets first and returns "button" — short-circuiting the BaseBoolField => "toggle" arm — so each remove_checkbox_{key} Toggle is published with role=button and the Java overlay creates an E2ETextView instead of an E2ECheckBox. UiAutomator2 then sees android.widget.TextView (not CheckBox), isChecked()/UiSelector().checked(true)/getAttribute("checked") no longer reflect Toggle state, and applyText writes "1"/"0" as plain text instead of calling setChecked(). Click routing still works via the Q<BaseBoolField>(id) path, so the author's specs pass, but the semantic is wrong and future state assertions on these toggles can't work. Fix: run the type switch first and fall through to the AndroidClickTargets check only when the element isn't already a typed control.
Extended reasoning...
What the bug is
AccessibilityBridge.AndroidNativeRole (AccessibilityBridge.cs:728-740) is currently:
private static string AndroidNativeRole(VisualElement el)
{
if (AndroidClickTargets.ContainsKey(el))
return "button";
return el switch
{
TextField => "input",
BaseBoolField => "toggle",
OneSignalDemo.UI.SwitchToggle => "toggle",
Button => "button",
_ => "text",
};
}The AndroidClickTargets membership check runs before the type switch, so any element passed through RegisterE2ETapTarget is reported with role="button" regardless of what kind of VisualElement it actually is.
How it manifests through MultiSelectRemoveDialog
MultiSelectRemoveDialog.BuildContent (MultiSelectRemoveDialog.cs:60-69) registers both the row container and the Toggle itself as E2E tap targets:
AccessibilityBridge.RegisterE2ETapTarget(
row,
() => toggle.enabledInHierarchy,
() => ToggleSelection(item.Key, toggle));
AccessibilityBridge.RegisterE2ETapTarget(
toggle,
() => toggle.enabledInHierarchy,
() => ToggleSelection(item.Key, toggle));RegisterE2ETapTarget on Android unconditionally adds the element to AndroidClickTargets (AccessibilityBridge.cs:185-192). Toggle is a subclass of BaseBoolField, so AndroidNativeRole should return "toggle" — but the ContainsKey early return short-circuits, and the Toggle is published with role="button".
On the Java side (OneSignalUnityE2EAccessibility.java:147-152), createView only instantiates E2ECheckBox in the role.equals("toggle") branch; role="button" creates an E2ETextView with a click handler. Consequences:
- UiAutomator2 sees
android.widget.TextView, notandroid.widget.CheckBox.UiSelector().className("android.widget.CheckBox")won't match. isChecked()/UiSelector().checked(true)/getAttribute("checked")always return false / unset because TextView is not Checkable.applyTextguards thesetChecked(...)call withview instanceof E2ECheckBox; that branch is skipped, so the published value "1"/"0" lands as plain text on the TextView and the overlay never reflects the toggle's actual state.
Why the bug doesn't break the existing specs
InvokeAndroidNativeAction (AccessibilityBridge.cs:647-695) tries Q<SwitchToggle>(id), then Q<BaseBoolField>(id) first, and only falls through to the AndroidClickTargets loop if neither matches. The Toggle matches Q<BaseBoolField>, so the value-flip path runs before the click action — clicking the overlay TextView still toggles the underlying Toggle. The PR's numbered specs apparently pass because they assert via content-description / text attribute reads, not via CheckBox state.
Step-by-step proof (single toggle in Remove Selected Tags)
- User adds tag
foo=bar.tags_cardlists the pair. - User taps
remove_tags_button.HomeScreenController.ShowRemoveSelectedTagsDialogopensMultiSelectRemoveDialogwith one item,("foo", "bar"). BuildContentcreatestoggle.name = "remove_checkbox_foo"and callsAccessibilityBridge.RegisterE2ETapTarget(toggle, ...)—AndroidClickTargets[toggle] = ....AccessibilityBridge.SyncAndroidNativeAccessibilitywalks_mapand callssyncElementfor the toggle.AndroidNativeRole(toggle)checksAndroidClickTargets.ContainsKey(toggle)→ true → returns"button"(it never reaches theBaseBoolField => "toggle"arm).OneSignalUnityE2EAccessibility.createViewseesrole.equals("button")→ createsE2ETextView, notE2ECheckBox. The overlay doessetContentDescription("0")andapplyText(view, "0")— TextView branch sets text to "0". CheckBox class is never attached.- Appium calls
driver.findElement(AppiumBy.androidUIAutomator("new UiSelector().resourceId(\"remove_checkbox_foo\").checked(false)")). UiAutomator2's accessibility snapshot reports the element's class asandroid.widget.TextViewwith nocheckedattribute — the selector misses. - Even with a coarser selector,
element.getAttribute("checked")returns null/false regardless of the Toggle's actual value, because the overlay view isn't a CheckBox.
Impact
- Demo-only, but the PR's stated motivation is preparing the demo for Appium E2E tests, and Remove Selected Tags / Remove Selected Triggers are the canonical "CheckBox" flows for the test suite to assert.
- Scope is limited to MultiSelectRemoveDialog today (no other caller registers a
BaseBoolFieldas an Android click target), but the misclassification would silently spread the moment any other Toggle/Radio is wired throughRegisterE2ETapTarget. - The author's currently-green specs use clicks and text reads, so this regression doesn't block them — but any new spec that uses
UiSelector().checked(true),isSelected(), orgetAttribute("checked")will fail until this is fixed.
Fix
Run the type switch first, fall through to AndroidClickTargets only for plain VisualElements:
private static string AndroidNativeRole(VisualElement el) => el switch
{
TextField => "input",
BaseBoolField => "toggle",
OneSignalDemo.UI.SwitchToggle => "toggle",
Button => "button",
_ when AndroidClickTargets.ContainsKey(el) => "button",
_ => "text",
};This preserves the "button" override for unnamed/plain registered targets (e.g. the row container in MultiSelectRemoveDialog, which is also registered as a tap target) while letting typed controls keep their semantic role.
| private const string ApsEnvironment = "development"; | ||
|
|
||
| private const string NseTargetName = "OneSignalNotificationServiceExtension"; | ||
| private const string WidgetTargetName = "OneSignalWidget"; |
There was a problem hiding this comment.
🔴 🔴 SigningPostProcessor.WidgetTargetName is set to "OneSignalWidget", but BuildPostProcessor.AddAppExtension registers the widget under "OneSignalWidgetExtension" (BuildPostProcessor.cs:48, :128). project.TargetGuidByName("OneSignalWidget") returns null, ApplyExtensionFixup logs the "Target not found" warning and returns, so PRODUCT_BUNDLE_IDENTIFIER is never rewritten to {appId}.LA and DEVELOPMENT_TEAM is never pinned. The widget keeps its default bundle ID com.onesignal.example.OneSignalWidgetExtension, which mismatches the new iOS/ExportOptions.plist mapping for com.onesignal.example.LA → "Appium Demo - Live Activity", so xcodebuild -exportArchive will fail to find a matching provisioning profile. Fix: private const string WidgetTargetName = "OneSignalWidgetExtension";.
Extended reasoning...
What the bug is
examples/demo/Assets/App/Editor/iOS/SigningPostProcessor.cs:37 declares:
private const string WidgetTargetName = "OneSignalWidget";That string is the widget extension's folder/relative path (WidgetExtensionTargetRelativePath in BuildPostProcessor.cs:46), not the Xcode target name. The Xcode target itself is registered with a different name.
The specific code path that triggers it
BuildPostProcessor.cs:48 defines WidgetExtensionTargetName = "OneSignalWidgetExtension" and BuildPostProcessor.cs:128 calls:
extensionGuid = project.AddAppExtension(
project.GetUnityMainTargetGuid(),
WidgetExtensionTargetName, // "OneSignalWidgetExtension"
$"{PlayerSettings.GetApplicationIdentifier(BuildTargetGroup.iOS)}.{WidgetExtensionTargetName}",
$"{WidgetExtensionTargetRelativePath}/Info.plist"
);So the PBX target is literally named OneSignalWidgetExtension with a default bundle ID of {appId}.OneSignalWidgetExtension.
SigningPostProcessor.ApplyExtensionFixup (lines 130–147) then calls project.TargetGuidByName("OneSignalWidget"). No such target exists, so guid is null/empty and the function hits its early-return:
if (string.IsNullOrEmpty(guid))
{
Debug.LogWarning($"[SigningPostProcessor] Target '{targetName}' not found; skipping.");
return;
}Neither SetBuildProperty(guid, "PRODUCT_BUNDLE_IDENTIFIER", bundleId) nor ApplyTeamId(...) runs for the widget target.
Why existing code doesn't prevent it
There is no callsite that resolves the target by file path; TargetGuidByName requires the literal PBX target name. The sibling NseTargetName constant ("OneSignalNotificationServiceExtension") happens to match the SDK's NSE target name, masking the inconsistency for the NSE case. Only the widget constant is wrong.
Step-by-step proof
- Unity build emits the Xcode project.
BuildPostProcessor(callbackOrder 45) runs first, callsAddAppExtension(..., "OneSignalWidgetExtension", ...). PBX now contains a target namedOneSignalWidgetExtensionwith bundle IDcom.onesignal.example.OneSignalWidgetExtension. SigningPostProcessor(callbackOrder 100) runs.FixupSigningAndBundleIdsreads the PBXProject, then invokesApplyExtensionFixup(project, "OneSignalWidget", "com.onesignal.example.LA").project.TargetGuidByName("OneSignalWidget")→ null. The IsNullOrEmpty branch fires, logs[SigningPostProcessor] Target 'OneSignalWidget' not found; skipping., and returns.PRODUCT_BUNDLE_IDENTIFIERon the widget target is never rewritten andDEVELOPMENT_TEAMis never pinned. The widget retainscom.onesignal.example.OneSignalWidgetExtension.- CI runs
xcodebuild -exportArchive -exportOptionsPlist iOS/ExportOptions.plist.ExportOptions.plistdeclaressigningStyle = manualwith provisioningProfiles keyed bycom.onesignal.example.LA→ "Appium Demo - Live Activity". xcodebuild walks the archive's targets, finds the widget's actual bundle IDcom.onesignal.example.OneSignalWidgetExtension, looks up that key in the dictionary, finds nothing, and fails withNo profile for bundle identifier 'com.onesignal.example.OneSignalWidgetExtension'.
Impact
CI/build-time regression introduced by this PR. The PR description explicitly lists "Normalizes extension bundle IDs to short suffixes (.NSE, .LA)" and adds iOS/ExportOptions.plist keyed by those suffixes; the misnamed constant defeats both. NSE works because its constant happens to match; only the widget is broken.
Fix
One-line change at SigningPostProcessor.cs:37:
private const string WidgetTargetName = "OneSignalWidgetExtension";| #if UNITY_IOS || UNITY_ANDROID | ||
| using System; | ||
| using System.Collections; | ||
| using System.Collections.Generic; | ||
| using UnityEngine; | ||
| using UnityEngine.Accessibility; |
There was a problem hiding this comment.
🔴 🔴 AccessibilityBridge.cs wraps its entire class body in #if UNITY_IOS || UNITY_ANDROID (file:1), so the type doesn't exist on Standalone (Windows/Mac/Linux) build targets — but multiple new callers reference it without the same guard: DialogBase.cs (47, 80, 111, 125), SectionBuilder.cs (50, 115, 125, 175, 231), ToastView.cs (39, 48), MultiPairInputDialog.cs (59, 138), MultiSelectRemoveDialog.cs (60, 65), SendIamSectionController.cs (80). On a fresh checkout opened on macOS/Windows, Unity's Active Build Target defaults to Standalone (the per-user EditorUserBuildSettings.asset lives in the gitignored Library/), so neither UNITY_IOS nor UNITY_ANDROID is defined and the demo fails to compile with CS0103 'name AccessibilityBridge does not exist' across ~12 sites until the developer manually switches Build Target to iOS or Android. Fix: drop the file-level gate on AccessibilityBridge.cs and rely on the per-method #if UNITY_ANDROID && !UNITY_EDITOR / #if UNITY_IOS && !UNITY_EDITOR blocks already inside the class, or add stub no-op shells for the public API on non-mobile targets.
Extended reasoning...
What the bug is
examples/demo/Assets/Scripts/Services/AccessibilityBridge.cs is a new file in this PR. Line 1 opens #if UNITY_IOS || UNITY_ANDROID; the matching #endif is at EOF. So the public class AccessibilityBridge : MonoBehaviour declaration plus every static method on it (EnableForE2E, RegisterE2ETapTarget, RequestResync, RequestImmediateResync) only exists when the Active Build Target is iOS or Android.
The author clearly knew this was a constraint: HomeScreenController.cs:65 wraps its EnableForE2E(_root) call in #if UNITY_IOS || UNITY_ANDROID, and SecondaryScreenController.cs:42,72 gates similarly. But several other new callsites added in this PR reference AccessibilityBridge unconditionally:
DialogBase.cs:47(AccessibilityBridge.RequestImmediateResync()inShow),:80(inDismiss),:111(RegisterE2ETapTargetinCreateConfirmButton),:125(inCreateCancelButton)SectionBuilder.cs:50(RegisterE2ETapTargetfor the info icon),:115,:125(CreatePrimaryButton/CreateDestructiveButton),:175,:231(delete buttons insideCreateKeyValueItem/CreateSingleItem)ToastView.cs:39(RequestImmediateResyncinShow),:48(inHide)MultiPairInputDialog.cs:59(RegisterE2ETapTargetfor the add-row button),:138(RequestImmediateResyncafter add)MultiSelectRemoveDialog.cs:60,65(RegisterE2ETapTargetfor the row + toggle)SendIamSectionController.cs:80(RegisterE2ETapTargetfor IAM buttons)
Why this fires on a fresh checkout
examples/demo/ProjectSettings/EditorBuildSettings.asset only stores the scene list — the Active Build Target is recorded in Library/EditorUserBuildSettings.asset, which is gitignored. So when a new developer clones the repo and opens the demo on macOS or Windows, Unity falls back to its host-OS Standalone default (StandaloneOSX / StandaloneWindows64). Under those targets neither UNITY_IOS nor UNITY_ANDROID is defined, the AccessibilityBridge type symbol vanishes, and every unguarded callsite above fails with CS0103: The name 'AccessibilityBridge' does not exist in the current context.
The using OneSignalDemo.Services; directive in those files resolves to an empty type set when the bridge's body is preprocessed out — there is no companion stub class. The demo Scripts folder has no .asmdef restricting compilation to mobile platforms either, so the same csproj is compiled for whatever the Active Build Target is.
Step-by-step proof
- New contributor:
git clone …/OneSignal-Unity-SDK && cd examples/demo. There is noLibrary/checked in. - Open the project in Unity 6000.4.6f1 on macOS. Unity creates
Library/EditorUserBuildSettings.assetdefaultingactiveBuildTargettoStandaloneOSX. - Unity recompiles
Assembly-CSharp.dll.AccessibilityBridge.csis excluded from compilation by its file-level#if UNITY_IOS || UNITY_ANDROID. - Roslyn reaches
DialogBase.cs:47:AccessibilityBridge.RequestImmediateResync();. TheAccessibilityBridgeidentifier resolves to nothing in scope →error CS0103. - Same error repeats at the ~12 other unguarded callsites listed above. Editor reports compile errors and refuses to enter Play mode until the developer manually switches Build Target via File → Build Profiles to iOS or Android.
Why existing code doesn't prevent it
The author's two-pronged gate (file-level on the implementation + #if UNITY_IOS || UNITY_ANDROID around EnableForE2E callsites in screen controllers) only catches the small set of callsites that received explicit guards. The much larger surface introduced in this PR — every dialog and section that registers tap targets / requests resyncs — was not guarded, presumably because the author was developing with Build Target already pinned to iOS or Android. There is no asmdef-level platform constraint to catch the inconsistency mechanically.
Impact
Demo-only — the SDK code under com.onesignal.unity.* is untouched, so production users are not affected. But this is a real first-open foot-gun for new contributors and any reviewer who checks out the branch on macOS or Windows: the demo won't compile, and the fix (switch Active Build Target) is non-obvious to someone who hasn't read the PR description.
Fix
Two clean options, both single-file edits:
- Remove the file-level gate on
AccessibilityBridge.csand rely on the per-method internal#if UNITY_ANDROID && !UNITY_EDITOR/#if UNITY_IOS && !UNITY_EDITORblocks that are already liberally used inside the methods (e.g.RegisterE2ETapTarget's body,SyncAndroidNativeAccessibility). The class would expose the same public surface on every target, with method bodies that no-op on Standalone. - Add stub no-op shells for the public API in a
#elsebranch at the end of the file. Same effect, slightly more code.
Either fix unblocks Standalone compilation without touching the dozen call sites or weakening the existing per-method platform branching.
| var boolField = _root.Q<BaseBoolField>(id); | ||
| if (boolField != null) | ||
| { | ||
| boolField.value = !boolField.value; | ||
| return; | ||
| } |
There was a problem hiding this comment.
🟡 On Android in E2E mode, clicking an already-selected RadioButton in OutcomeDialog flips its value to false via the unconditional boolField.value = !boolField.value in InvokeAndroidNativeAction (AccessibilityBridge.cs:663-667). Because RadioButton subclasses BaseBoolField and the SelectType callbacks only fire on newValue=true, the radio visually deselects with no other radio becoming active, while _selectedType silently retains its previous value. Fix: special-case RadioButton so re-clicking an already-selected radio is a no-op (e.g. if (boolField is RadioButton && boolField.value) return;) or set value = true rather than flip for RadioButton subclasses.
Extended reasoning...
What the bug is
AccessibilityBridge.InvokeAndroidNativeAction (AccessibilityBridge.cs:663-668) handles all BaseBoolField targets the same way:
var boolField = _root.Q<BaseBoolField>(id);
if (boolField != null)
{
boolField.value = !boolField.value;
return;
}The author's own comment at lines 659-662 acknowledges that BaseBoolField is the common base of Toggle and RadioButton, but applies Toggle's flip semantics to both. UI Toolkit's built-in RadioButton.Clickable manipulator sets value=true unconditionally — a user click on an already-selected radio is a no-op, not a deselect. The bridge's flip diverges from that and can leave the radio group with nothing selected.
Code path that triggers it (Android E2E)
OutcomeDialog uses loose RadioButtons in a plain VisualElement (OutcomeDialog.cs:37) — not a RadioButtonGroup — so Unity's auto-mutex group semantics don't apply. The handler at OutcomeDialog.cs:43-57 only acts on newValue=true:
_normalRadio.RegisterValueChangedCallback(e => { if (e.newValue) SelectType(OutcomeType.Normal); });The radios are not registered with RegisterE2ETapTarget, so AndroidNativeRole falls through to the BaseBoolField => "toggle" arm (AccessibilityBridge.cs:733). Java creates an E2ECheckBox overlay, click round-trips into InvokeAndroidNativeAction, and the BaseBoolField branch flips the value.
Step-by-step proof
- OutcomeDialog opens.
_normalRadio.value = true(set by CreateRadio at line 100 before callbacks register, so no callback fires during init)._selectedType = Normal. - Android E2E test taps
outcome_type_normal_radio(e.g. to assert the default selection before flowing onward). - Java overlay's
OnClickListenersendsclickto Unity.HandleAndroidAccessibilityActionroutes toInvokeAndroidNativeAction("outcome_type_normal_radio"). Q<SwitchToggle>returns null.Q<BaseBoolField>matches_normalRadio(RadioButton extends BaseBoolField).boolField.value = !true = false. Callback fires withnewValue=false. The handler guardif (e.newValue)skipsSelectType.- End state:
_normalRadio.value = false,_uniqueRadio.value = false,_withValueRadio.value = false. Visually no radio is selected._selectedTypeis stillNormal(functional flow OK), butExtractValuenow returns"0"for all three radios — so any accessibility assertion againstgetAttribute("value")reads as if no outcome type is selected.
Why this is narrow
Functional flow still works: SendOutcome reads _selectedType, which is preserved across the flip. The PR's referenced specs (01_ through 12_) don't appear to exercise this re-click pattern on the outcome radios, which is why they pass. The misbehavior only surfaces when a test or user re-clicks an already-selected radio — uncommon in test design, but real and surprising when it hits. Demo-only, E2E-only, no SDK impact.
Fix
One-line guard:
var boolField = _root.Q<BaseBoolField>(id);
if (boolField != null)
{
if (boolField is RadioButton && boolField.value) return;
boolField.value = !boolField.value;
return;
}Or set value = true rather than flip for RadioButton subclasses to mirror the built-in Clickable manipulator.
Description
One Line Summary
Prepare the Unity demo app for Appium E2E test automation by adding accessibility bridges, stabilizing UI element naming, and improving iOS/Android test reliability.
Details
Motivation
SDK-4406: enable Appium-driven E2E tests against the Unity demo app. Unity's UI Toolkit does not expose native accessibility identifiers, so Appium cannot reliably locate elements on iOS or Android. This PR adds native accessibility bridges (iOS and Android), standardizes UI element names, and fixes a number of demo-side bugs uncovered while wiring up the test harness.
Scope
examples/demo/is touched. No SDK source changes.AccessibilityBridgeplus iOS (OneSignalDemoKeyboard.mm) and Android (OneSignalUnityE2EAccessibility.java) native helpers used to expose UI elements to Appium.LogView/LogManager(replaced withDebug.Log), renamesTrackEventSectionControllertoCustomEventsSectionController, consolidates upsert helpers, replaces the loading overlay with inline loading states, and tightens dialog/keyboard behavior.Testing
Manual testing
./run-local.sh --sdk=unity --platform=android --spec="{01_,02_,03_}".--skip-buildfor01_through12_; all numbered specs passed.Affected code checklist
Checklist
Overview
Testing
Final pass
Made with Cursor